其他
厉害,最优雅的Android列表项可见性检测!
大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。
详情见文章:没错!皇叔开了个训练营
作者:唐子玄
https://juejin.cn/post/7165428399282847757
1.引子
业务开发中列表项的曝光埋点做得越来越精细了。
// RecyclerView.Adapter.kt
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
ReportUtil.reportShow("material-item-show",materialId)
}
这样的话,就无法在 onBindViewHolder() 触发埋点上报了。
2.现有方案
// 监听列表滚动事件
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
// 获取线性布局管理器(假设)
val layoutManager = recycler.layoutManager as LinearLayoutManager
// 获取布局管理器中的第一个/最后一个表项索引
val firstPosition = layoutManager.findFirstVisibleItemPosition()
val lastPosition = layoutManager.findLastVisibleItemPosition()
// 遍历可见表项逐个计算可见百分比
for (pos in firstPosition..lastPosition) {
val view = layoutManager.findViewByPosition(pos)
if (view != null) {
val percentage = getVisibleHeightPercentage(view)
}
}
}
// 计算表项可见百分比
private fun getVisibleHeightPercentage(view: View): Double {
// 获取表项可见矩形区域
val itemRect = Rect()
val isParentViewEmpty = view.getLocalVisibleRect(itemRect)
// 获取表项应有高度
val visibleHeight = itemRect.height().toDouble()
val height = view.getMeasuredHeight()
// 获取表项高度可见百分比(假设)
val viewVisibleHeightPercentage = visibleHeight / height * 100
if(isParentViewEmpty){
return viewVisibleHeightPercentage
}else{
return 0.0
}
}
})
列表项使用线性布局管理器 LinearLayoutManager。 列表是纵向滑动的(所以只要计算高度百分比就好)。
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
val layoutManager = recycler.layoutManager
if( layoutManager is LinearLayoutManager) {
...
} else if( layoutManager is GridLayoutManager) {
...
} else if( layoutManager is StaggeredGridLayoutManager) {
...
}
}
})
和类型相关的问题,如果使用 if-else 来讨论,那就没有扩展性可言。
3.类型无关列表项可见性检测
fun RecyclerView.addOnItemVisibilityChangeListener(
percent: Float = 0.5f, // 列表项可见性阈值
block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
val scrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {}
}
addOnScrollListener(scrollListener)
}
fun RecyclerView.onItemVisibilityChange(
percent: Float = 0.5f,
viewGroups: List<ViewGroup> = emptyList(),
block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
// 可复用的矩形区域,避免重复创建
val childVisibleRect = Rect()
// 记录所有可见表项搜索的列表
val visibleAdapterIndexs = mutableSetOf<Int>()
// 将列表项可见性检测定义为一个 lambda
val checkVisibility = {
// 遍历所有 RecyclerView 的子控件
for (i in 0 until childCount) {
val child = getChildAt(i)
// 获取其适配器索引
val adapterIndex = getChildAdapterPosition(child)
if(adapterIndex == NO_POSITION) continue
// 计算子控件可见区域并获取是否可见标记位
val isChildVisible = child.getLocalVisibleRect(childVisibleRect)
// 子控件可见面积
val visibleArea = childVisibleRect.let { it.height() * it.width() }
// 子控件真实面积
val realArea = child.width * child.height
// 比对可见面积和真实面积,若大于阈值,则回调可见,否则不可见
if (isChildVisible && visibleArea >= realArea * percent) {
if (visibleAdapterIndexs.add(adapterIndex)) {
block(child, adapterIndex, true)
}
} else {
if (adapterIndex in visibleAdapterIndexs) {
block(child, adapterIndex, false)
visibleAdapterIndexs.remove(adapterIndex)
}
}
}
}
// 为列表添加滚动监听器
val scrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
checkVisibility()
}
}
addOnScrollListener(scrollListener)
// 避免内存泄漏,当列表被移除时,反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}
override fun onViewDetachedFromWindow(v: View?) {
if (v == null || v !is RecyclerView) return
v.removeOnScrollListener(scrollListener)
removeOnAttachStateChangeListener(this)
}
})
}
结合全网最优雅安卓控件可见性检测中检测控件可见性的方案,修改如下:
https://juejin.cn/post/7165427955902971918
fun RecyclerView.onItemVisibilityChange(
percent: Float = 0.5f,
viewGroups: List<ViewGroup>? = null,
block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
val childVisibleRect = Rect()
val visibleAdapterIndexs = mutableSetOf<Int>()
val checkVisibility = {
for (i in 0 until childCount) {
val child = getChildAt(i)
val adapterIndex = getChildAdapterPosition(child)
if(adapterIndex == NO_POSITION) continue
val isChildVisible = child.getLocalVisibleRect(childVisibleRect)
val visibleArea = childVisibleRect.let { it.height() * it.width() }
val realArea = child.width * child.height
if (this.isInScreen && isChildVisible && visibleArea >= realArea * percent) {
if (visibleAdapterIndexs.add(adapterIndex)) {
block(child, adapterIndex, true)
}
} else {
if (adapterIndex in visibleAdapterIndexs) {
block(child, adapterIndex, false)
visibleAdapterIndexs.remove(adapterIndex)
}
}
}
}
val scrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
checkVisibility()
}
}
addOnScrollListener(scrollListener)
// 为列表添加全局可见性检测
onVisibilityChange(viewGroups,false) { view, isVisible ->
// 当列表可见时,检测其表项的可见性
if (isVisible) {
checkVisibility()
} else {
// 当列表不可见时,回调所有可见表项为不可见
for (i in 0 until childCount) {
val child = getChildAt(i)
val adapterIndex = getChildAdapterPosition(child)
if (adapterIndex in visibleAdapterIndexs) {
block(child, adapterIndex, false)
visibleAdapterIndexs.remove(adapterIndex)
}
}
}
}
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}
override fun onViewDetachedFromWindow(v: View?) {
if (v == null || v !is RecyclerView) return
v.removeOnScrollListener(scrollListener)
removeOnAttachStateChangeListener(this)
}
})
}
4.ViewPager2 页可见性检测
ViewPager2 是对 RecycerView 的二次封装,理论上可以复用 RecyclerView 的可见性检测方案。
fun ViewPager2.addOnPageVisibilityChangeListener(block: (index: Int, isVisible: Boolean) -> Unit) {
// 当前页
var lastPage: Int = currentItem
// 注册页滚动监听器
val listener = object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
// 回调上一页不可见
if (lastPage != position) {
block(lastPage, false)
}
// 回调当前页可见
block(position, true)
lastPage = position
}
}
registerOnPageChangeCallback(listener)
// 避免内存泄漏
addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}
override fun onViewDetachedFromWindow(v: View?) {
if (v == null || v !is ViewPager2) return
if (ViewCompat.isAttachedToWindow(v)) {
v.unregisterOnPageChangeCallback(listener)
}
removeOnAttachStateChangeListener(this)
}
})
}
利用 ViewPager2 提供的 OnPageChangeCallback,在内部记录了上一页,以此来向上层回调上一页不可见事件。
为了防止失联,欢迎关注我防备的小号
微信改了推送机制,真爱请星标本公号👇